<script>
  const VALID_DIRECTION = new Set(['left', 'right', 'top', 'bottom'])
  const VALID_PARTICLE_TYPE = new Set(['circle', 'rectangle', 'triangle'])
  const VALID_PARTICLE_STYLE = new Set(['fill', 'stroke'])
  const DEFAULT_SIZE_FUNC = () => Math.floor(Math.random() * 3 + 1)
  const DEFAULT_SPEED_FUNC = () => rand(4)

  const bezier = (() => {
    const kSplineTableSize = 11
    const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0)

    function A(aA1, aA2) {
      return 1.0 - 3.0 * aA2 + 3.0 * aA1
    }
    function B(aA1, aA2) {
      return 3.0 * aA2 - 6.0 * aA1
    }
    function C(aA1) {
      return 3.0 * aA1
    }

    function calcBezier(aT, aA1, aA2) {
      return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT
    }
    function getSlope(aT, aA1, aA2) {
      return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1)
    }

    function binarySubdivide(aX, aA, aB, mX1, mX2) {
      let currentX
      let currentT
      let i = 0
      do {
        currentT = aA + (aB - aA) / 2.0
        currentX = calcBezier(currentT, mX1, mX2) - aX
        if (currentX > 0.0) {
          aB = currentT
        } else {
          aA = currentT
        }
      } while (Math.abs(currentX) > 0.0000001 && ++i < 10)
      return currentT
    }

    function newtonRaphsonIterate(aX, aGuessT, mX1, mX2) {
      for (let i = 0; i < 4; ++i) {
        const currentSlope = getSlope(aGuessT, mX1, mX2)
        if (currentSlope === 0.0) return aGuessT
        const currentX = calcBezier(aGuessT, mX1, mX2) - aX
        aGuessT -= currentX / currentSlope
      }
      return aGuessT
    }

    function bezier(mX1, mY1, mX2, mY2) {
      if (!(mX1 >= 0 && mX1 <= 1 && mX2 >= 0 && mX2 <= 1)) {
        return x => x
      }
      let sampleValues = new Float32Array(kSplineTableSize)

      if (mX1 !== mY1 || mX2 !== mY2) {
        for (let i = 0; i < kSplineTableSize; ++i) {
          sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2)
        }
      }

      function getTForX(aX) {
        let intervalStart = 0.0
        let currentSample = 1
        const lastSample = kSplineTableSize - 1

        for (
          ;
          currentSample !== lastSample && sampleValues[currentSample] <= aX;
          ++currentSample
        ) {
          intervalStart += kSampleStepSize
        }

        --currentSample

        const dist =
          (aX - sampleValues[currentSample]) /
          (sampleValues[currentSample + 1] - sampleValues[currentSample])
        const guessForT = intervalStart + dist * kSampleStepSize
        const initialSlope = getSlope(guessForT, mX1, mX2)

        if (initialSlope >= 0.001) {
          return newtonRaphsonIterate(aX, guessForT, mX1, mX2)
        } else if (initialSlope === 0.0) {
          return guessForT
        } else {
          return binarySubdivide(
            aX,
            intervalStart,
            intervalStart + kSampleStepSize,
            mX1,
            mX2
          )
        }
      }

      return x => {
        if (mX1 === mY1 && mX2 === mY2) return x
        if (x === 0) return 0
        if (x === 1) return 1
        return calcBezier(getTForX(x), mY1, mY2)
      }
    }

    return bezier
  })()

  const easings = (() => {
    const names = [
      'Quad',
      'Cubic',
      'Quart',
      'Quint',
      'Sine',
      'Expo',
      'Circ',
      'Back',
    ]

    const equations = {
      In: [
        [0.55, 0.085, 0.68, 0.53] /* InQuad */,
        [0.55, 0.055, 0.675, 0.19] /* InCubic */,
        [0.895, 0.03, 0.685, 0.22] /* InQuart */,
        [0.755, 0.05, 0.855, 0.06] /* InQuint */,
        [0.47, 0.0, 0.745, 0.715] /* InSine */,
        [0.95, 0.05, 0.795, 0.035] /* InExpo */,
        [0.6, 0.04, 0.98, 0.335] /* InCirc */,
        [0.6, -0.28, 0.735, 0.045] /* InBack */,
      ],
      Out: [
        [0.25, 0.46, 0.45, 0.94] /* OutQuad */,
        [0.215, 0.61, 0.355, 1.0] /* OutCubic */,
        [0.165, 0.84, 0.44, 1.0] /* OutQuart */,
        [0.23, 1.0, 0.32, 1.0] /* OutQuint */,
        [0.39, 0.575, 0.565, 1.0] /* OutSine */,
        [0.19, 1.0, 0.22, 1.0] /* OutExpo */,
        [0.075, 0.82, 0.165, 1.0] /* OutCirc */,
        [0.175, 0.885, 0.32, 1.275] /* OutBack */,
      ],
      InOut: [
        [0.455, 0.03, 0.515, 0.955] /* InOutQuad */,
        [0.645, 0.045, 0.355, 1.0] /* InOutCubic */,
        [0.77, 0.0, 0.175, 1.0] /* InOutQuart */,
        [0.86, 0.0, 0.07, 1.0] /* InOutQuint */,
        [0.445, 0.05, 0.55, 0.95] /* InOutSine */,
        [1.0, 0.0, 0.0, 1.0] /* InOutExpo */,
        [0.785, 0.135, 0.15, 0.86] /* InOutCirc */,
        [0.68, -0.55, 0.265, 1.55] /* InOutBack */,
      ],
    }

    const functions = {
      linear: bezier(0.25, 0.25, 0.75, 0.75),
    }

    for (let type in equations) {
      equations[type].forEach((f, i) => {
        functions['ease' + type + names[i]] = bezier.apply(this, f)
      })
    }

    return functions
  })()

  function rand(value) {
    return Math.random() * value - value / 2
  }

  function isFunc(value) {
    return typeof value === 'function'
  }

  const me = new WeakMap()

  export default {
    props: {
      hidden: {
        type: Boolean,
        default: false,
      },
      direction: {
        type: String,
        default: 'left',
        validator(value) {
          return VALID_DIRECTION.has(value)
        },
      },
      particleType: {
        type: String,
        default: 'circle',
        validator(value) {
          return VALID_PARTICLE_TYPE.has(value)
        },
      },
      particleStyle: {
        type: String,
        default: 'fill',
        validator(value) {
          return VALID_PARTICLE_STYLE.has(value)
        },
      },
      particleColor: {
        type: String,
        default: '#000',
      },
      duration: {
        type: Number,
        default: 1000,
      },
      easing: {
        type: [String, Array],
        default: 'easeInOutCubic',
        validator(value) {
          if (Array.isArray(value)) {
            return value.length === 4 && value.every(Number.isFinite)
          } else {
            return value != null
          }
        },
      },
      canvasPadding: {
        type: Number,
        default: 150,
      },
      size: {
        type: [Number, Function],
        default: () => DEFAULT_SIZE_FUNC,
      },
      speed: {
        type: [Number, Function],
        default: () => DEFAULT_SPEED_FUNC,
      },
      particlesAmountCoefficient: {
        type: Number,
        default: 3,
      },
      oscillationCoefficient: {
        type: Number,
        default: 20,
      },
    },
    data() {
      return {
        status: this.hidden ? 'hidden' : 'normal',
        progress: this.hidden ? 0 : 1,
        rect: {
          width: 0,
          height: 0,
        },
      }
    },
    computed: {
      canvasStyles() {
        const style = {}
        if (this.status === 'hidden' || this.status === 'normal') {
          style.visibility = 'hidden'
        }
        return style
      },
      wrapperStyles() {
        const style = {}
        const prop = this.transformStyleProp
        const px = this.transformStylePx

        if (this.status === 'hiding' || this.status === 'showing') {
          style.transform = `${prop}(${px}px)`
        } else if (this.status === 'hidden') {
          style.visibility = 'hidden'
        }

        return style
      },
      contentStyles() {
        const style = {}
        const prop = this.transformStyleProp
        const px = -this.transformStylePx

        if (this.status === 'hiding' || this.status === 'showing') {
          style.transform = `${prop}(${px}px)`
        }

        return style
      },
      isHorizontal() {
        return this.direction === 'left' || this.direction === 'right'
      },
      transformStyleProp() {
        return this.isHorizontal ? 'translateX' : 'translateY'
      },
      transformStylePx() {
        const size = this.isHorizontal ? this.rect.width : this.rect.height
        const value =
          this.direction === 'left' || this.direction === 'top'
            ? this.progress
            : -this.progress

        return this.direction === 'left' || this.direction === 'top'
          ? Math.ceil(size * value)
          : Math.floor(size * value)
      },
    },
    watch: {
      hidden(newValue) {
        if (this.status === 'normal' && newValue) {
          this.status = 'hiding'
          this.$nextTick(this.startAnimation)
        } else if (this.status === 'hidden' && !newValue) {
          this.status = 'showing'
          this.$nextTick(this.startAnimation)
        }
      },
    },
    created() {
      me.set(this, {
        particles: [],
        lastProgress: 0,
        inAnimation: false,
        beginTimeStamp: 0,
        endTimeStamp: 0,
        easing: easings.linear,
      })
    },
    updated() {
      window.requestAnimationFrame(this.afterDomUpdate)
    },
    methods: {
      afterDomUpdate(now) {
        if (now < me.get(this).endTimeStamp) {
          this.doAnimation(now)
          return
        }
        switch (this.status) {
          case 'hiding':
            this.progress = 1
            break
          case 'showing':
            this.progress = 0
            break
          default:
            break
        }
      },
      startAnimation() {
        if (!this.$refs) return
        if (!this.$refs.canvasRef) return
        if (!this.$refs.wrapperRef) return

        const data = me.get(this)

        if (this.status === 'hiding') {
          data.lastProgress = 0
          this.progress = 1
        } else {
          data.lastProgress = 1
          this.progress = 0
        }
        if (Array.isArray(this.easing)) {
          data.easing = bezier.apply(this, this.easing)
        } else {
          data.easing = easings[this.easing] || easings.linear
        }
        data.beginTimeStamp = window.performance.now()
        data.endTimeStamp = data.beginTimeStamp + this.duration
        data.particles = []

        const wrapperRect = this.$refs.wrapperRef.getBoundingClientRect()
        this.$refs.canvasRef.width = wrapperRect.width + this.canvasPadding * 2
        this.$refs.canvasRef.height = wrapperRect.height + this.canvasPadding * 2
        this.rect.width = wrapperRect.width
        this.rect.height = wrapperRect.height

        this.$emit('begin')
        this.$nextTick(() => this.doAnimation(data.beginTimeStamp))
      },
      doAnimation(timestamp) {
        const { beginTimeStamp, endTimeStamp, easing } = me.get(this)
        const now = Math.min(timestamp, endTimeStamp)
        let p = (now - beginTimeStamp) / this.duration

        if (this.status === 'showing') {
          p = 1 - p
        }
        this.progress = easing(p)

        if (this.duration) {
          this.addParticles(p)
        }
      },
      loop() {
        this.updateParticles()
        this.renderParticles()

        const data = me.get(this)

        if (data.particles.length) {
          data.inAnimation = true
          window.requestAnimationFrame(this.loop)
        } else {
          data.inAnimation = false
          this.cycleStatus()
          this.$emit('complete')
        }
      },
      addParticles(progress) {
        const { width, height } = this.rect
        const { lastProgress, inAnimation } = me.get(this)
        const delta =
          this.status === 'hiding'
            ? progress - lastProgress
            : lastProgress - progress
        const progressValue =
          (this.isHorizontal ? width : height) * progress +
          delta * (this.status === 'hiding' ? 1000 : 100)

        me.get(this).lastProgress = progress

        let x = this.canvasPadding
        let y = this.canvasPadding

        if (this.isHorizontal) {
          x += this.direction === 'left' ? progressValue : width - progressValue
        } else {
          y += this.direction === 'top' ? progressValue : height - progressValue
        }

        let i = Math.floor(this.particlesAmountCoefficient * (delta * 100 + 1))
        if (i > 0) {
          while (i--) {
            this.addParticle(
              x + (this.isHorizontal ? 0 : width * Math.random()),
              y + (this.isHorizontal ? height * Math.random() : 0)
            )
          }
        }

        if (!inAnimation) {
          window.requestAnimationFrame(this.loop)
          me.get(this).inAnimation = true
        }
      },
      addParticle(startX, startY) {
        const frames = this.duration * 60 / 1000
        const speed = isFunc(this.speed) ? this.speed() : this.speed
        const size = isFunc(this.size) ? this.size() : this.size

        me.get(this).particles.push({
          startX,
          startY,
          x: this.status === 'hiding' ? 0 : speed * -frames,
          y: 0,
          angle: rand(360),
          counter: this.status === 'hiding' ? 0 : frames,
          increase: Math.PI * 2 / 100,
          life: 0,
          death:
            this.status === 'hiding' ? frames - 20 + Math.random() * 40 : frames,
          speed,
          size,
        })
      },
      updateParticles() {
        const { particles } = me.get(this)

        for (let i = 0; i < particles.length; i++) {
          const p = particles[i]

          if (p.life > p.death) {
            particles.splice(i, 1)
          } else {
            p.x += p.speed
            p.y = this.oscillationCoefficient * Math.sin(p.counter * p.increase)
            p.life++
            p.counter += this.status === 'hiding' ? 1 : -1
          }
        }
      },
      renderParticles() {
        const canvas = this.$refs.canvasRef
        const ctx = canvas.getContext('2d')
        const { particles } = me.get(this)

        ctx.clearRect(0, 0, canvas.width, canvas.height)
        ctx.fillStyle = ctx.strokeStyle = this.particleColor

        for (let i = 0; i < particles.length; ++i) {
          const p = particles[i]

          if (p.life < p.death) {
            ctx.translate(p.startX, p.startY)
            ctx.rotate(p.angle * Math.PI / 180)
            ctx.globalAlpha =
              this.status === 'hiding' ? 1 - p.life / p.death : p.life / p.death
            ctx.beginPath()

            if (this.particleType === 'circle') {
              ctx.arc(p.x, p.y, p.size, 0, 2 * Math.PI)
            } else if (this.particleType === 'triangle') {
              ctx.moveTo(p.x, p.y)
              ctx.lineTo(p.x + p.size, p.y + p.size)
              ctx.lineTo(p.x + p.size, p.y - p.size)
            } else if (this.particleType === 'rectangle') {
              ctx.rect(p.x, p.y, p.size, p.size)
            }

            if (this.particleStyle === 'fill') {
              ctx.fill()
            } else if (this.particleStyle === 'stroke') {
              ctx.closePath()
              ctx.stroke()
            }

            ctx.globalAlpha = 1
            ctx.rotate(-p.angle * Math.PI / 180)
            ctx.translate(-p.startX, -p.startY)
          }
        }
      },
      cycleStatus() {
        switch (this.status) {
          case 'normal':
            this.status = 'hiding'
            break
          case 'hidden':
            this.status = 'showing'
            break
          case 'hiding':
            this.status = 'hidden'
            break
          case 'showing':
            this.status = 'normal'
            break
          default:
            break
        }
      },
    },
  }
</script>

<template>
  <div :class="$style.particles">
    <div
      ref="wrapperRef"
      :style="wrapperStyles"
      :class="$style.wrapper"
    >
      <div
        :style="contentStyles"
        :class="$style.content"
      >
        <slot />
      </div>
    </div>
    <canvas
      ref="canvasRef"
      :style="canvasStyles"
      :class="$style.canvas"
    />
  </div>
</template>

<style module>
  .particles {
    position: relative;
    display: inline-block;
  }

  .wrapper {
    position: relative;
    display: inline-block;
    overflow: hidden;
  }

  .content:focus,
  .content > *:focus {
    outline: none;
  }

  .canvas {
    position: absolute;
    top: 50%;
    left: 50%;
    pointer-events: none;
    transform: translate3d(-50%, -50%, 0);
  }
</style>
